fix(client)!: oauth.applications.delete resolves on the zero-byte 200 its route answers, instead of rejecting on every successful delete - #15675
Conversation
…ts route answers
`ObjectStackClient.oauth.applications.delete` ended `return res.json()` on a
route that answers HTTP 200 with a ZERO-BYTE body, so it rejected with
`SyntaxError: Unexpected end of JSON input` on EVERY successful delete — after
the row had already been committed away server-side. The method had no success
path a caller could observe, and the obvious recovery (retry) failed
DIFFERENTLY, with the route's 404 `not_found`.
Measured end to end, not inherited: real betterAuth + real oauthProvider over
the real ObjectQL adapter, a real signed-up user and session, driven through the
real client with only the socket stood in for.
POST /oauth2/delete-client -> 200 · 0 bytes · content-type application/json
· NO content-length header
through the client, before -> REJECTED: SyntaxError
the row, server-side -> ALREADY GONE (get-client answers 404)
through the client, after -> RESOLVED | undefined
Emptiness is detected by READING the body. Both shortcuts were measured and both
are unusable here: the status is 200, not the 204 five other delete surfaces in
this file key off, and the response carries no `content-length` header at all.
A non-empty body is still parsed and its failure still thrown, so the ONLY
behaviour that moves is the zero-byte case. `void` is the wire fact: "deleted"
and "was already gone" are distinguished on the ERROR channel (404 `not_found`,
raised by `this.fetch` before any success value exists), so a synthesised
`{ deleted: true }` would be a shape the wire never sends.
`exported-any-returns.json` loses this method's entry in the same change — the
ledger is shrink-only, so the entry goes WITH the binding. Its last `oauth.*`
entry is now gone; 35 sites remain open. The `toEqualTypeOf<any>()` pin PR
#15445 left behind for exactly this moment is replaced by
`returnTypePrecisionPins15451`, and the reject/resolve flip — which no
compile-time assertion can observe — is pinned in a new runtime suite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
….delete` `**BREAKING**` on two independent axes: the declared return moves off an erased `any` onto `void` (a compile break, though only for reads of a value the promise never produced), and the runtime flips from always-rejecting to resolving, so a caller's `catch` stops firing on success.⚠️ The ADR-0087 disposition is NOT claimed, and the gate is expected to red on this changeset until a maintainer settles it. Both legs were measured rather than guessed: type-surface-only REFUSED at predicate 4. A reference is a bare identifier resolved to the FIRST same-named definition, and index.ts declares TEN members named `delete`; the first (line 2397) is unannotated at both revs, so the gate reports "still UNANNOTATED" about a member this diff never touched. Issue #15627, filed off PR #15445 where the same ambiguity cost the `get` member its place in the marker — here it blocks the only member there is. no-migration-prescription mechanically ACCEPTED, and deliberately not taken. ADR-0087's D7 records that #8277 held this exemption on a detector MISS rather than a positive finding, and names that as the pattern the sixth category exists to stop. Taking it here, with the measurement in hand, would repeat it knowingly. Dropping the `**BREAKING**` token is the third exit and ADR-0087's addendum closes it for this class in as many words. So the honest state is a loud red on one gate with the reasoning written down, rather than a green held by a category that does not describe this diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 47c5322f97a3f6d26450d67ccd7bbf542196dcae && git checkout 47c5322f97a3f6d26450d67ccd7bbf542196dcae
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7bf96cfd04fd5ef15bdd6b8ff76c4559c5ad1349 3fd460ee4028b266986453d2f903cb80af9e1990 && git checkout -B drift-repro 7bf96cfd04fd5ef15bdd6b8ff76c4559c5ad1349 && git merge --no-ff 3fd460ee4028b266986453d2f903cb80af9e1990
node scripts/docs-audit/affected-docs.mjs --json 7bf96cfd04fd5ef15bdd6b8ff76c4559c5ad1349
|
Seat status — the code is done; the block is a governed-surface decision, escalated⛔ Not flipped, not enqueued. The decision has been escalated to #15674, which now carries Why I am not choosing between the four optionsTwo of them edit ⭐ One thing I want on the record because it cuts against my own convenience: option A edits My recommendation to the maintainer is A (repair the addressing), with B as a parallel prose fix — four axes all point the same way, so there is no trade-off to present. The full analysis is on #15674. What you did that I want to name
⛔ Both process notes are defects in MY dispatch, not yours
Generated by Claude Code |
Fixes #15451
ObjectStackClient.oauth.applications.deleteendedreturn res.json()on a route that answers HTTP 200 with a zero-byte body, so it rejected withSyntaxError: Unexpected end of JSON inputon every successful delete — after the row had already been committed away server-side. The method had no success path a caller could observe, and the obvious recovery (retry) failed differently, with the route's 404not_found.The fifth and last method of the
oauth.*family, and the one #14312 / PR #15445 deliberately could not close: that ruling fenced the card to narrowing published return types, and no declared return type can be true while theres.json()call stands.Re-driven, not inherited
The card's premise was re-measured on this branch rather than taken from the card. Real
betterAuth+ real@better-auth/oauth-providerover the real ObjectQL adapter on real SQLite, a real signed-up user and a real session, driven through the realObjectStackClientwith only the socket stood in for (@objectstack/verify's in-process app), against the showcase stack:The three sibling methods answering normally on the same run are the harness's positive control: this is not a probe that failed to reach the route. The 404 after the client threw confirms the nastiest part of the card — the caller sees failure for a completed destructive operation, and the natural retry then produces a second, differently-shaped failure.
The harness was a throwaway; it is not in this diff. Everything it proved is pinned below at a level that does not need a stack boot.
The two open questions the card handed over, answered by measurement
1. What should the method resolve to?
void. The route does distinguish "deleted" from "was already gone" — but on the error channel: a client that is not there answers 404{ error: 'not_found' }, whichthis.fetchalready raises as a throw before any success value exists. The 200 answer carries zero bytes and therefore zero information, so a synthesised{ deleted: true }would be a shape the wire never sends and strictly less informative than the 404 the caller already receives. The vendor agrees from its own side:StrictEndpoint('/oauth2/delete-client', ..., void).2. How should the emptiness be detected? By reading the body. Both shortcuts were measured against the real route and both are unusable:
res.status === 204— the spelling five other delete surfaces in this file usecontent-length === '0'null), not zero''on successWhat it costs the day the route grows a payload: a non-empty body is still parsed, and its failure still thrown — so a malformed response stays loud and the only behaviour this change moves is the zero-byte case. A well-formed payload is validated and discarded under the declared
void; surfacing it would be a deliberate, reviewable widening of the return type, never a silent change of shape. The parse is therefore load-bearing rather than dead code, and it is pinned as such.What is in the diff
packages/client/src/index.tspackages/client/exported-any-returns.jsonoauth.*entry is gonepackages/client/src/oauth-applications-delete.test.tspackages/client/src/return-type-precision.test.tstoEqualTypeOf(any)line PR #15445 left behind "to be replaced when the open decision lands" is replaced byreturnTypePrecisionPins15451.changeset/client-oauth-delete-zero-byte-200.mdminor,**BREAKING**. See the blocker belowBoth halves the triage comment asked for are here: the ledger entry goes in the same PR as the binding, and there is a pin that the method resolves on a zero-byte 200 — so restoring
res.json()"for symmetry with its three siblings" cannot happen silently.Its positive control is carried too:
automation.trigger, bound by PR #13082, is absent from the ledger, so the zero I am reading is a real absence and not a mis-read of the file.The BREAKING question, answered explicitly
BREAKING on two independent axes, and it makes a published method usable for the first time:
anyontovoid. A read like(await ...delete(id)).deletedstops compiling (TS2339).catchstops firing on success.try { await ...delete(id) } catch {}still compiles and still runs, but its catch block was executing on every successful delete and now executes only on a real failure. Any workaround inside it is now inert.Dropping the
**BREAKING**token is not an available exit: ADR-0087's 2026-08-30 addendum closes it for this class in as many words.check:adr-0087-registrationreds on this changeset. That is deliberate and it is the only red. Both legs were measured, not guessed:Leg 1 — the accurate category is unspellable.
not-required (type-surface-only ...)is refused at predicate 4:A reference is a bare identifier resolved to the first same-named definition, and this file declares ten members named
delete. The first (line 2397) is unannotated at both revs, so the gate reports a true sentence about a member this diff never touched.parseSymbolRefrequires a bare identifier, so the natural disambiguator with dots is rejected before any reading happens. That is #15627, filed off PR #15445 where the same ambiguity cost thegetmember its place in the marker — here it blocks the only member there is. Measurement added to that card.Leg 2 — the one category that IS accepted is not claimed, deliberately.
not-required (no-migration-prescription)passes mechanically: the prescription detector finds nothing in this body. It is not taken, because ADR-0087's own D7 records that PR #8277 held exactly this exemption on a detector miss rather than a positive finding, and names that as the pattern the sixth category was created to stop. Taking it here, with the measurement in hand, would repeat it knowingly.⇒ Three exits, each closed by something already written down. A loud red with the reasoning recorded beats a green held by a category that does not describe the diff. The changeset carries a clearly-labelled non-claim note saying so, so the question is answered in writing even though the marker is absent — which is what the gate's own header says it exists to require.
The taxonomy half is filed as #15674; the mechanical half is #15627; the neighbouring open shape is #14502. Any of them is a
docs/adr/**edit — governed, maintainer's by hand. This PR touches no governed surface (packages/client/**and.changeset/only).Verification — all at
3fd460ee402Derived gate union —
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived from a tree atorigin/mainafter the first run raised its STALE TREE banner (5 commits behind). Change set: exactly the 5 paths above. 19 of 21 green on the first pass, and the two non-zero results were both NOT MEASURED rather than red, so both were converted into real measurements:check:dual-build-cjs-loadsexit 3 —PREREQUISITE NOT MET ... nothing was measured. Built the 7 named packages, re-ran: exit 0, 103 require entry points across 66 packages load.check:changeset-no-majorexit 254 —ERR_PNPM ... script not found; my invocation was wrong, not the gate. Rannode scripts/check-changeset-no-major.mjs: exit 0.Every exit code above was captured after redirection to a file, never through a pipe.
Both test files were proven to be inside the tsc program with
tsc -p tsconfig.test.json --listFilesbefore any claim was made about them — one hit each.Ablation
Direction predicted in writing first: reverting
index.tsalone, with the guards kept, should turn 4 of the 7 runtime pins red, breaktypecheck, and turn the ledger gate red in the unlisted direction. Observed, all three:The three pins that stayed green are the ones that were green in both states and are labelled as such in the file: the malformed body, the whitespace-only body, and the 404. Restore was under an
EXIT INT TERMtrap using absolute paths, then proven three ways — blob-hash equality with the HEAD blob, an emptygit diff HEAD, and an empty whole-treegit status --porcelain— anddistwas rebuilt afterwards and re-checked for the marker, because the restore leg only restores source.Sequencing
packages/client/src/index.tsis the #12104 family's measured hard serial. It was held by nobody at the ref this branch was cut from, and nothing that landed onorigin/mainin the meantime touched it (re-checked at merge). Cards #15447 and #12104 queue behind this file.🤖 Generated with Claude Code
https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Generated by Claude Code